iT邦幫忙

2026 iThome 鐵人賽

DAY 12
0
JavaScript

從 JavaScript 到 TypeScript:30 天建立 AI 時代的型別思維系列 第 12 篇

day12|可辨識聯集:一個欄位決定要帶哪些資料

  • 分享至 

  • xImage
  •  

上一篇練習用條件判斷縮小型別,讓程式知道目前是哪一種可能。這次來看怎麼把資料寫清楚,讓每種狀態都對得上該有的欄位。

題目請求可能正在載入、已取得題目,或載入失敗;這幾種情況最多只會有一種成立。若各用一個布林值記錄,TypeScript 卻會接受下面這筆資料:

type QuestionViewFlags = {
  isLoading: boolean;
  hasQuestion: boolean;
  hasError: boolean;
};

const view: QuestionViewFlags = {
  isLoading: true,
  hasQuestion: true,
  hasError: true,
};

三個欄位各自合法,合起來卻表示三種情況同時成立。型別沒有寫出互斥規則,TypeScript 就無法指出這筆資料的問題。

讓題型決定有哪些欄位

可辨識聯集(discriminated union)把互斥的資料形式分成數個物件型別,再用聯集組合。每個分支都有同一個判別欄位,值各不相同;TypeScript 便能用這個值辨認資料屬於哪個分支。

type Question =
  | { type: "choice"; prompt: string; options: readonly string[] }
  | { type: "fill"; prompt: string; answer: string };

type 是兩個分支共同的判別欄位。它的值會決定該題型必須有哪些資料:

  • type: "choice":選擇題必須有 options;漏掉時,TypeScript 會指出錯誤。
  • type: "fill":填空題必須有 answer。

比較下面把兩種題目寫在同一個物件型別的做法:

type LooseQuestion = {
  type: "choice" | "fill";
  prompt: string;
  options?: readonly string[];
  answer?: string;
};

const missingOptions: LooseQuestion = {
  type: "choice",
  prompt: "哪個關鍵字用來匯出?",
};

options? 表示選項可以省略,所以這筆選擇題會通過檢查。前面的 Question 聯集則把 type: "choice" 和必填的 options 放在同一個分支。

讀取題目時先判斷 type,各分支才能使用自己的欄位:

function describeQuestion(question: Question): string {
  if (question.type === "choice") {
    return `選擇題,共 ${question.options.length} 個選項`;
  }

  return `填空題,答案長度是 ${question.answer.length}`;
}

把請求狀態和資料放在一起

題目請求可能尚未開始、正在載入、成功或失敗。成功才有題目資料,失敗才有錯誤資訊:

type RequestState =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: readonly Question[] }
  | { status: "error"; error: Error };

status 是判別欄位;成功時必須提供題目陣列 data,失敗時則必須提供 error。

如果把四種狀態寫在同一個物件型別,並讓 data 和 error 都可以省略,就會變成這樣:

type LooseState = {
  status: "idle" | "loading" | "success" | "error";
  data?: readonly Question[];
  error?: Error;
};

LooseState 會接受成功卻沒有 data,也會接受失敗卻沒有 error。檢查 status 後,TypeScript 仍無法確定對應欄位存在。改用前面的聯集,畫面就能根據 status 讀取資料:

function questionMessage(state: RequestState): string {
  switch (state.status) {
    case "idle":
      return "尚未載入題目";
    case "loading":
      return "題目載入中";
    case "success":
      return `已載入 ${state.data.length} 題`;
    case "error":
      return `載入失敗:${state.error.message}`;
  }
}

空陣列也可能是成功結果。畫面看 status,就能區分「已載入 0 題」和「尚未載入」。這個型別列出可存在的狀態;請求何時從載入轉為成功或失敗,由執行時的流程決定。

左側三個獨立布林欄位允許載入中、有題目、發生錯誤同時成立;右側的 status 分成 idle、loading、success、error 四種狀態,只有 success 連到 data,只有 error 連到錯誤資訊。

延伸到 Angular:這個概念在框架中如何出現?

若元件的 state 使用 RequestState,模板可以這樣寫:

@switch (state.status) {
  @case ("idle") { <p>尚未載入題目</p> }
  @case ("loading") { <p>題目載入中</p> }
  @case ("success") { <p>已載入 {{ state.data.length }} 題</p> }
  @case ("error") { <p>載入失敗:{{ state.error.message }}</p> }
}

模板控制流程 / Template control flow
Angular 模板可以用 @switch 依 state.status 選出要顯示的 @case;語法見 Angular 控制流程。

今日練習

day12 | 型旅 TypeTrail

結論

遇到互斥狀態,先把每種狀態和它必備的資料寫成同一個分支。TypeScript 才能在建立資料時檢查必填欄位,也能在判斷狀態後縮小型別。

當值的型別尚未確定,或要確認所有分支都處理完,就要再分清 any、unknown 與 never。


本文同步分享於個人部落格:https://johnsonchen.dev/blog/typescript/discriminated-union/


上一篇
day11|型別縮小:讓條件判斷幫忙確認型別
下一篇
day13|any、unknown、never:處理未知資料與遺漏分支
系列文
從 JavaScript 到 TypeScript:30 天建立 AI 時代的型別思維 共 13 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言